/*
 * Document.java
 * 
 * Copyright (c) 2010 Jaymie Strecker (strecker at cs.umd.edu). All rights reserved.
 * 
 * This file is part of Tonic.
 * 
 * Tonic is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 * 
 * Tonic is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 * 
 * You should have received a copy of the GNU General Public License
 * along with Tonic.  If not, see <http://www.gnu.org/licenses/>.
 */

package tonic.audio;

import java.io.BufferedInputStream;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileWriter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Vector;
import java.util.zip.CRC32;
import java.util.zip.CheckedInputStream;
import java.util.zip.Checksum;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;

/**
 * Document represents a document to be saved or loaded. It includes information about
 * the audio file and the marked excerpts. It can set the state of these items based on
 * the document.
 *
 * Saving a file is accomplished with one method (save). Opening a file is accomplished by
 * calls to 3 methods: (1) read, (2) setAudioFile()-- or, if that fails,  setAudioFile(File)--
 * and (3) setExcerptList. 
 *
 * @author Jaymie Strecker (strecker at cs.umd.edu)
 */
public class Document {
    private static final String FILE_SEP = System.getProperty("file.separator"); 
    private File audioFile;
    private long checksum;
    private String audioFileAbsolute = null;
    private String audioFileRelative = null; 
    private ArrayList<Excerpt> excerptList;
    private static final long CHECKSUM_ERROR = 0;

    /**
     * Constructs a document initialized based on the Player's current audio data. 
     */
    private Document(File audioFile, long checksum, ArrayList<Excerpt> excerptList) {
        this.audioFile = audioFile;
        this.checksum = checksum;
        this.excerptList = excerptList; 
    }

    /**
     * Factory method - Use to get a Document when opening a file.
     */
    public static Document getEmptyDocument() {
        return new Document(null, CHECKSUM_ERROR, null);
    }

    /**
     * Factory method - Use to get a Document when saving a file. 
     */
    public static Document getCurrentDocument() {
        File audioFile = Player.getFile();
        long checksum = makeChecksum(audioFile);
        ArrayList<Excerpt> excerptList = new ArrayList<Excerpt>();
        for (int i = 0; i < Player.getNumExcerpts(); i++) {
            Excerpt e = Player.getExcerpt(i);
            excerptList.add(e);
        }
        return new Document(audioFile, checksum, excerptList);
    }

    /**
     * Loads the document from the given file. Does not affect the state of any other
     * audio objects. 
     *
     * @param file  a previously saved file
     * @throws IOException  if there is a problem reading the file
     */
    public void read(File file) throws IOException {
        /* File format:
         * <xml ...>
         * <tonic>
         * <audiofile>
         *      <absolutepath>...</absolutepath>
         *      <relativepath>...</relativepath>
         *      <checksum>...</checksum>
         * </audiofile>
         * <excerptlist>
         *      <excerpt>
         *          <startsamplenum>...</startsamplenum>
         *          <endsamplenum>...</endsamplenum>
         *          <rankedpitchlist>
         *              <rankedpitch>
         *                  <octave>...</octave>
         *                  <semitone>...</semitone>
         *                  <amplitude>...</amplitude>
         *              </rankedpitch>
         *              ...
         *          </rankedpitchlist>
         *      </excerpt>
         *      ...
         * </excerptlist>
         * </tonic>
         */
        DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
        try {
            DocumentBuilder db = dbf.newDocumentBuilder();
            org.w3c.dom.Document dom = db.parse(file);
            Element tonicElem = dom.getDocumentElement();
            if (! tonicElem.getTagName().equals("tonic")) {
                throw new IOException("Expected root tag of file to be 'tonic', but found '" +
                        tonicElem.getTagName() + "'");
            }
            Element audioFileElem = (Element) tonicElem.getElementsByTagName("audiofile").item(0);
            Element absolutePathElem = (Element) audioFileElem.getElementsByTagName("absolutepath").item(0);
            audioFileAbsolute = absolutePathElem.getFirstChild().getNodeValue();

            Element relativePathElem = (Element) audioFileElem.getElementsByTagName("relativepath").item(0);
            File audioFileRelativeParent = file.getParentFile();
            audioFileRelative = new File(audioFileRelativeParent,
                    relativePathElem.getFirstChild().getNodeValue()).getPath();

            Element checksumElem = (Element) audioFileElem.getElementsByTagName("checksum").item(0);
            checksum = new Long(checksumElem.getFirstChild().getNodeValue()).longValue(); 

            Element excerptListElem = (Element) tonicElem.getElementsByTagName("excerptlist").item(0);
            NodeList excerptNodeList = excerptListElem.getElementsByTagName("excerpt");
            excerptList = new ArrayList<Excerpt>();

            for (int i = 0; i < excerptNodeList.getLength(); i++) {
                Element excerptElem = (Element) excerptNodeList.item(i);
                Element startSampleNumElem = (Element) excerptElem.getElementsByTagName("startsamplenum").item(0);
                int startSampleNum = new Integer(startSampleNumElem.getFirstChild().getNodeValue()).intValue();

                Element endSampleNumElem = (Element) excerptElem.getElementsByTagName("endsamplenum").item(0);
                int endSampleNum = new Integer(endSampleNumElem.getFirstChild().getNodeValue()).intValue();

                Element rankedPitchListElem = (Element) excerptElem.getElementsByTagName("rankedpitchlist").item(0);
                NodeList rankedPitchNodeList = rankedPitchListElem.getElementsByTagName("rankedpitch");
                Vector<PitchAmplitude> rankedPitchList = new Vector<PitchAmplitude>(); 

                for (int j = 0; j < rankedPitchNodeList.getLength(); j++) {
                    Element rankedPitchElem = (Element) rankedPitchNodeList.item(j);
                    Element octaveElem = (Element) rankedPitchElem.getElementsByTagName("octave").item(0);
                    int octave = new Integer(octaveElem.getFirstChild().getNodeValue()).intValue();

                    Element semitoneElem = (Element) rankedPitchElem.getElementsByTagName("semitone").item(0);
                    int semitone = new Integer(semitoneElem.getFirstChild().getNodeValue()).intValue();

                    Element amplitudeElem = (Element) rankedPitchElem.getElementsByTagName("amplitude").item(0);
                    double amplitude = new Double(amplitudeElem.getFirstChild().getNodeValue()).doubleValue();

                    rankedPitchList.addElement(new PitchAmplitude(new Pitch(octave, semitone), amplitude));
                }
                excerptList.add(new Excerpt(startSampleNum, endSampleNum, rankedPitchList));
            }

        } catch (Exception e) {
            throw new IOException(e);
        }
    }

    /**
     * Pre: read has successfully been called
     *
     * Sets the audio file of the Player's current audio data based on the document.
     *
     * Tries to set the audio file using the absolute path. If that doesn't work, tries
     * using the relative path. 
     *
     * @throws IOException  if neither the absolute path nor the relative path is valid,
     *                          or if neither has the expected checksum
     */
    public void setAudioFile() throws IOException {
        File absolute = new File(audioFileAbsolute);
        try {
            long absoluteChecksum = makeChecksum(absolute);
            if (checksum != CHECKSUM_ERROR && absoluteChecksum != CHECKSUM_ERROR && 
                    checksum != absoluteChecksum) {
                throw new IOException("Checksum mismatch");
            }
            Player.setFile(absolute);
            audioFile = absolute;
        } catch (Exception e) {
            File relative = new File(audioFileRelative);
            try {
                long relativeChecksum = makeChecksum(relative);
                if (checksum != CHECKSUM_ERROR && relativeChecksum != CHECKSUM_ERROR &&
                        checksum != relativeChecksum) {
                    throw new IOException("Checksum mismatch");
                }
                Player.setFile(relative);
                audioFile = relative;
            } catch (Exception ex) {
                audioFile = absolute; 
                throw new IOException(ex);
            }
        }
    }

    /**
     * Sets the audio file of the Player's current audio data to the given file.
     *
     * @param f     a readable audio file
     * @throws IOException  if there is a problem setting the Player's audio data (but
     *                        does not check checksums)
     */
    public void setAudioFile(File f) throws IOException {
        Player.setFile(f);
    }

    /**
     * Pre: read and setAudioFile have successfully been called. 
     *
     * Sets the excerpt list of the Player's current audio data based on the document.
     */
    public void setExcerptList() {
        Player.setExcerpts(excerptList);
    }

    /**
     * Returns the audio file. 
     */
    public File getAudioFile() {
        return audioFile;
    }

    /**
     * Saves the document to the given file. Assumes that all excerpts have been analyzed. 
     *
     * @param file  a writeable file
     * @throws IOException  if there is a problem writing to the file
     */
    public void save(File file) throws IOException {
        try {
            BufferedWriter out = new BufferedWriter(new FileWriter(file));
            writeLine(out, "<?xml version=\"1.0\"?>");
            writeLine(out, "<tonic>");
            writeLine(out, "<audiofile>");
            writeLine(out, makeElement("absolutepath", audioFile.getAbsolutePath()));
            writeLine(out, makeElement("relativepath", relativePath(audioFile, file)));
            writeLine(out, makeElement("checksum", checksum)); 
            writeLine(out, "</audiofile>");
            writeLine(out, "<excerptlist>");
            for (Excerpt e : excerptList) {
                writeLine(out, "<excerpt>");
                writeLine(out, makeElement("startsamplenum", e.getStartSampleNum()));
                writeLine(out, makeElement("endsamplenum", e.getEndSampleNum()));
                writeLine(out, "<rankedpitchlist>");
                Vector<PitchAmplitude> r = e.getRankedPitches();
                for (PitchAmplitude pa : r) {
                    writeLine(out, "<rankedpitch>");
                    writeLine(out, makeElement("octave", pa.pitch.getOctaveNum()));
                    writeLine(out, makeElement("semitone", pa.pitch.getSemitoneNum()));
                    writeLine(out, makeElement("amplitude", pa.amplitude));
                    writeLine(out, "</rankedpitch>");
                }
                writeLine(out, "</rankedpitchlist>");
                writeLine(out, "</excerpt>");
            }
            writeLine(out, "</excerptlist>");
            writeLine(out, "</tonic>");
            out.close();
        } catch (FileNotFoundException ex) {
            throw new IOException(ex);
        }
    }

    private static void writeLine(BufferedWriter out, String line) throws IOException {
        out.write(line);
        out.newLine(); 
    }

    private static String makeElement(String name, String value) {
        StringBuffer s = new StringBuffer();
        s.append("<");
        s.append(name);
        s.append(">");
        s.append(value);
        s.append("</");
        s.append(name);
        s.append(">");
        return s.toString(); 
    }

    private static String makeElement(String name, int value) {
        return makeElement(name, Integer.toString(value));
    }

    private static String makeElement(String name, long value) {
        return makeElement(name, Long.toString(value));
    }

    private static String makeElement(String name, double value) {
        return makeElement(name, Double.toString(value)); 
    }

    /**
     * Returns a string representation of the path of the first file relative to the second.
     *
     * @param file  the file to get the path of
     * @param relTo the file the path should be relative to
     */
    private static String relativePath(File file, File relTo) {
        Vector<String> fileParents = listParents(file);
        Vector<String> relToParents = listParents(relTo);
        int firstDiff = 0;
        while (firstDiff < fileParents.size() &&
                firstDiff < relToParents.size() &&
                fileParents.get(firstDiff).equals(relToParents.get(firstDiff))) {
            firstDiff++; 
        }
        StringBuffer relPath = new StringBuffer();
        for (int i = firstDiff; i < relToParents.size(); i++) {
            relPath.append("..");
            relPath.append(FILE_SEP);
        }
        for (int i = firstDiff; i < fileParents.size(); i++) {
            relPath.append(fileParents.get(i));
            relPath.append(FILE_SEP);
        }
        relPath.append(file.getName()); 
        return relPath.toString(); 
    }

    /**
     * Returns a vector representation of the file's path, starting from the root end. 
     */
    private static Vector<String> listParents(File f) {
        Vector<String> parents = new Vector<String>();
        for (File p = f.getParentFile(); p != null; p = p.getParentFile()) {
            parents.addElement(p.getName());
        }
        Collections.reverse(parents); 
        return parents;
    }

    /**
     * Returns a checksum for the given file. If there is a problem calculating the
     * checksum, returns CHECKSUM_ERROR.
     */
    private static long makeChecksum(File f) {
        long checksum = CHECKSUM_ERROR;
        try {
            FileInputStream file = new FileInputStream(f);
            CheckedInputStream check = new CheckedInputStream(file, new CRC32());
            BufferedInputStream in = new BufferedInputStream(check);
            while (in.read() != -1) {
                // Read file in completely
            }
            checksum = check.getChecksum().getValue();
        } catch (Exception e) {
            // nop
        }
        return checksum;
    }

}
